10 — 纹理

Three.js Journey · Bruno Simon · 难度:简单

10 — 纹理 课程海报

介绍

对你的红色立方体已经腻了吗?是时候加一些纹理了。

但首先,纹理是什么,我们又能真正拿它们做什么?

什么是纹理?

纹理,你应该知道,就是覆盖在几何体表面的图片。很多类型的纹理可以对你几何体的外观产生不同的影响。不只是颜色。

这里是最常见的纹理类型,使用的是 João Paulo 的一张著名的门纹理。如果你喜欢他的作品,请他喝杯Ko-fi,或者成为他的Patreon支持者。

颜色(或 albedo)

albedo 纹理是最简单的一种。它只会取纹理的像素并应用到几何体上。

Alpha

alpha 纹理是一张灰度图,白色部分可见,黑色部分不可见。

高度

高度纹理是一张灰度图,会移动顶点来制造一些起伏。如果你想看到它,你需要添加细分。

法线

法线纹理能添加微小的细节。它不会移动顶点,但会骗过光线,让光线以为那个面朝向不同。法线纹理非常适合在性能良好的情况下添加细节,因为你不需要细分几何体。

环境光遮蔽

环境光遮蔽纹理是一张灰度图,会在表面的缝隙处伪造阴影。虽然它在物理上并不精确,但它确实有助于制造对比度。

金属度

金属度纹理是一张灰度图,会指明哪个部分是金属(白色),哪个部分是非金属(黑色)。这个信息有助于生成反射。

粗糙度

粗糙度是一张与金属度配套的灰度图,会指明哪个部分粗糙(白色)、哪个部分光滑(黑色)。这个信息有助于让光线散射。地毯非常粗糙,你不会看到光在它上面反射;而水面非常光滑,你能看到光在它上面反射。这里这块木头是很均匀的,因为它上面有一层清漆。

PBR

这些纹理(尤其是金属度和粗糙度)遵循我们所说的 PBR 原则。PBR 是 Physically Based Rendering(基于物理的渲染)的缩写。它汇集了许多遵循真实世界规律的技术,以获得逼真的结果。

虽然还有很多其他技术,但 PBR 正在成为逼真渲染的标准,许多软件、引擎和库都在使用它。

目前,我们只专注于如何加载纹理、如何使用它们、能应用哪些变换,以及如何优化它们。我们会在以后的课程里更多了解 PBR,但如果你好奇,可以在这里了解更多:

如何加载纹理

获取图片的 URL

要加载纹理,我们需要图片文件的 URL。

因为我们使用的是构建工具,获取它有两种方式。

你可以把纹理图片放在 /src/ 文件夹里,然后像导入 JavaScript 依赖一样导入它:

import imageSource from './image.png'

console.log(imageSource)

或者你可以把图片放在 /static/ 文件夹里,只通过在 URL 路径中加上图片路径(不带 /static)来访问它:

const imageSource = '/image.png'

console.log(imageSource)

注意,这个 /static/ 文件夹只是因为是 Vite 模板的配置才能正常工作。如果你使用其他类型打包器,你可能需要调整你的项目。

课程剩下的部分我们会使用 /static/ 文件夹的技巧。

加载图片

你可以在 /static/ 文件夹里找到我们刚才看到的门纹理,加载它们有很多方式。

使用原生 JavaScript

用原生 JavaScript,首先,你必须创建一个 Image 实例,监听 load 事件,然后修改它的 src 属性开始加载图片:

const image = new Image()
image.onload = () =>
{
    console.log('image loaded')
}
image.src = '/textures/door/color.jpg'

你应该能在控制台看到 'image loaded'。如你所见,我们把源设置成 '/textures/door/color.jpg',路径里没有 /static 文件夹。

我们不能直接使用那张图片。我们得先从图片创建一个 Texture。

这是因为 WebGL 需要一种能被 GPU 访问的非常特定的格式,而且纹理还要应用一些修改,比如 mipmapping,我们稍后会详细说。

用 Texture 类创建纹理:

const image = new Image()
image.addEventListener('load', () =>
{
    const texture = new THREE.Texture(image)
})
image.src = '/textures/door/color.jpg'

我们现在需要做的是在 material 里使用那个 texture。遗憾的是,texture 变量是在一个函数里声明的,我们无法在函数外访问它。这是 JavaScript 的一个限制,叫做作用域(scope)。

我们可以在函数内部创建 mesh,但有一个更好的方案:在函数外创建纹理,然后等图片加载完成后再通过把纹理的 needsUpdate 属性设为 true 来更新它:

const image = new Image()
const texture = new THREE.Texture(image)
image.addEventListener('load', () =>
{
    texture.needsUpdate = true
})
image.src = '/textures/door/color.jpg'

这样做,你可以立即使用 texture 变量,图片在加载完成前会保持透明。

要在立方体上看到纹理,把 color 属性替换成 map,并传 texture 作为值:

const material = new THREE.MeshBasicMaterial({ map: texture })

你应该能在立方体的每个面看到门纹理。但是,纹理看起来奇怪地泛灰。

那是因为图片在编码时使用了 sRGB 色彩空间,但 Three.js 并不知道这一点。

要修复它,你需要把它们的 colorSpace 设为 THREE.sRGBColorSpace:

const texture = new THREE.Texture(image)
texture.colorSpace = THREE.SRGBColorSpace

现在你得到了正确的颜色。

总的思路是,用于材质 map 或 matcap(你以后会看到)属性的纹理,应该用 sRGB 编码,我们需要把 colorSpace 设为 THREE.sRGBColorSpace,但只针对这些。

更多关于色彩空间的内容,见Realistic Render(逼真渲染)课程。

使用 TextureLoader

原生 JavaScript 的技巧并不复杂,但用 TextureLoader 还有更直接的方式。

用 TextureLoader 类实例化一个变量,并用它的 .load(...) 方法创建纹理:

const textureLoader = new THREE.TextureLoader()
const texture = textureLoader.load('/textures/door/color.jpg')
texture.colorSpace = THREE.SRGBColorSpace

在内部,Three.js 会像之前那样加载图片,并在就绪后更新纹理。

你只需要一个 TextureLoader 实例就可以加载任意数量的纹理。

你可以在路径后面传 3 个函数。它们会在以下事件中被调用:

const textureLoader = new THREE.TextureLoader()
const texture = textureLoader.load(
    '/textures/door/color.jpg',
    () =>
    {
        console.log('loading finished')
    },
    () =>
    {
        console.log('loading progressing')
    },
    () =>
    {
        console.log('loading error')
    }
)
texture.colorSpace = THREE.SRGBColorSpace

如果纹理无法工作,加上这些回调函数来查看发生了什么、找到错误,可能会很有用。

使用 LoadingManager

最后,如果你要加载多张图片,并且想把事件集中管理,比如在所有图片都加载完时得到通知,你可以使用 LoadingManager。

创建 LoadingManager 类的实例,并把它传给 TextureLoader:

const loadingManager = new THREE.LoadingManager()
const textureLoader = new THREE.TextureLoader(loadingManager)

你可以通过把自己的函数替换掉下面这些属性来监听各种事件:onStart、onLoad、onProgress 和 onError:

const loadingManager = new THREE.LoadingManager()
loadingManager.onStart = () =>
{
    console.log('loading started')
}
loadingManager.onLoad = () =>
{
    console.log('loading finished')
}
loadingManager.onProgress = () =>
{
    console.log('loading progressing')
}
loadingManager.onError = () =>
{
    console.log('loading error')
}

const textureLoader = new THREE.TextureLoader(loadingManager)

现在你可以开始加载所有需要的图片了:

// ...

const colorTexture = textureLoader.load('/textures/door/color.jpg')
colorTexture.colorSpace = THREE.SRGBColorSpace
const alphaTexture = textureLoader.load('/textures/door/alpha.jpg')
const heightTexture = textureLoader.load('/textures/door/height.jpg')
const normalTexture = textureLoader.load('/textures/door/normal.jpg')
const ambientOcclusionTexture = textureLoader.load('/textures/door/ambientOcclusion.jpg')
const metalnessTexture = textureLoader.load('/textures/door/metalness.jpg')
const roughnessTexture = textureLoader.load('/textures/door/roughness.jpg')

如你在这里看到的,我们把 texture 变量重命名成了 colorTexture,所以别忘了在 material 里也一并修改:

const material = new THREE.MeshBasicMaterial({ map: colorTexture })

如果你想显示一个加载器,并只在所有资源加载完成时隐藏它,LoadingManager 会非常有用。正如我们在以后的课程中会看到的,你还可以把它和其他类型的加载器一起使用。

UV 展开

虽然把纹理放在立方体上是很符合逻辑的,但对其他几何体来说事情就有点棘手了。

试着把你的 BoxGeometry 换成其他几何体:

const geometry = new THREE.BoxGeometry(1, 1, 1)

// Or
const geometry = new THREE.SphereGeometry(1, 32, 32)

// Or
const geometry = new THREE.ConeGeometry(1, 1, 32)

// Or
const geometry = new THREE.TorusGeometry(1, 0.35, 32, 100)

如你所见,纹理以不同的方式被拉伸或挤压,以适应几何体。

这就叫做UV unwrapping(UV 展开)。你可以想象成把折纸或糖果包装纸展开摊平。每个顶点在平坦的(通常是正方形)平面上都有一个 2D 坐标。

你实际上可以在 geometry.attributes.uv 属性里看到这些 UV 2D 坐标:

console.log(geometry.attributes.uv)

使用基础几何体时,这些 UV 坐标由 Three.js 自动生成。如果你创建自己的几何体并且想给它应用纹理,你就得自己指定 UV 坐标。

如果你是使用 3D 软件制作几何体,你也需要做 UV 展开。

别担心;大多数 3D 软件也带有自动展开功能,应该能搞定。

变换纹理

让我们回到那个带一张纹理的立方体,看看我们可以对纹理应用哪些变换。

重复

你可以用 repeat 属性重复纹理,它是一个 Vector2,意味着它有 x 和 y 属性。

试着修改这些属性:

const colorTexture = textureLoader.load('/textures/door/color.jpg')
colorTexture.colorSpace = THREE.SRGBColorSpace
colorTexture.repeat.x = 2
colorTexture.repeat.y = 3

如你所见,纹理并没有重复,而是变小了,最后一个像素似乎被拉伸了。

那是因为纹理默认没有被设置成重复自身。要改变这一点,你必须使用 THREE.RepeatWrapping 常量更新 wrapS 和 wrapT 属性。

colorTexture.wrapS = THREE.RepeatWrapping
colorTexture.wrapT = THREE.RepeatWrapping

你也可以用 THREE.MirroredRepeatWrapping 交替方向:

colorTexture.wrapS = THREE.MirroredRepeatWrapping
colorTexture.wrapT = THREE.MirroredRepeatWrapping

偏移

你可以用 offset 属性偏移纹理,它也是一个带 x 和 y 属性的 Vector2。修改这些只会简单地偏移 UV 坐标:

colorTexture.offset.x = 0.5
colorTexture.offset.y = 0.5

旋转

你可以用 rotation 属性旋转纹理,它只是一个以弧度为单位的角度数字:

colorTexture.rotation = Math.PI * 0.25

如果你移除 offset 和 repeat 属性,你会看到旋转发生在立方体面的左下角附近:

那实际上就是 0, 0 的 UV 坐标。如果你想改变这个旋转的枢轴,你可以用 center 属性,它也是一个 Vector2:

colorTexture.rotation = Math.PI * 0.25
colorTexture.center.x = 0.5
colorTexture.center.y = 0.5

纹理现在会围绕它的中心旋转。

过滤与 Mipmapping

如果你看向立方体的顶面,而这个面几乎快被隐藏时,你会看到一个非常模糊的纹理。

那是由过滤和 mipmapping 造成的。

Mipmapping(或带空格的"mip mapping")是一种反复创建纹理一半大小版本、直到得到 1x1 纹理的技术。所有这些纹理变体都会被发送给 GPU,GPU 会选择最合适的纹理版本。

Three.js 和 GPU 已经处理了所有这一切,你只需要设置使用哪种过滤算法。过滤算法有两种类型:缩小过滤(minification filter)和放大过滤(magnification filter)。

缩小过滤

当纹理的像素小于渲染的像素时,就会发生缩小过滤。换句话说,纹理对它覆盖的表面来说太大了。

你可以用 minFilter 属性修改纹理的缩小过滤。

有 6 种可能的值:

默认值是 THREE.LinearMipmapLinearFilter。如果你对纹理的外观不满意,你应该试试其他过滤。

我们不会逐一查看,但我们会测试 THREE.NearestFilter,它的结果非常不同:

colorTexture.minFilter = THREE.NearestFilter
lesson demo video preview

如果你的设备像素比大于 1,你不会看出多大差别。如果不是,把相机移到这个面几乎被隐藏的位置,你就能得到更多细节和奇怪的伪影。

如果你用位于 /static/textures/ 文件夹里的 checkerboard-1024x1024.png 纹理测试,你会更清楚地看到那些伪影:

const colorTexture = textureLoader.load('/textures/checkerboard-1024x1024.png')
lesson demo video preview

你看到的伪影叫做 moiré patterns(摩尔纹),你通常要避免它们。

放大过滤

放大过滤的工作方式像缩小过滤,但发生在纹理的像素大于渲染的像素时。换句话说,纹理对它覆盖的表面来说太小了。

你可以用同样位于 static/textures/ 文件夹里的 checkerboard-8x8.png 纹理看到结果:

const colorTexture = textureLoader.load('/textures/checkerboard-8x8.png')

纹理变得完全模糊,因为这是一个非常小的纹理放在一个非常大的表面上。

虽然你可能觉得这很糟糕,但这样大概是最好的结果。如果效果不是过分夸张,用户可能根本不会注意到。

你可以用 magFilter 属性修改纹理的放大过滤。

只有两种可能的值:

默认值是 THREE.LinearFilter。

如果你测试 THREE.NearestFilter,你会看到基础图像被保留,得到像素化的纹理:

colorTexture.magFilter = THREE.NearestFilter

如果你想做 Minecraft 那种像素化纹理风格,这会很有利。

你可以用位于 static/textures/ 文件夹里的 minecraft.png 纹理看到结果:

const colorTexture = textureLoader.load('/textures/minecraft.png')

关于所有这些过滤器的最后一句:THREE.NearestFilter 比其他过滤器便宜,使用它你应该能得到更好的性能。

mipmaps 只用于 minFilter 属性。如果你用 THREE.NearestFilter,你不需要 mipmaps,可以用 colorTexture.generateMipmaps = false 关掉它们:

colorTexture.generateMipmaps = false
colorTexture.minFilter = THREE.NearestFilter

这会稍微减轻 GPU 的负担。

纹理格式与优化

在准备纹理时,你必须记住 3 个关键要素:

体积

别忘了,访问你网站的用户必须下载这些纹理。你可以使用我们在 Web 上使用的大多数图片格式,比如 .jpg(有损压缩,但通常更轻)或 .png(无损压缩,但通常更重)。

尽量应用常规方法来得到既能接受、又尽可能轻的图片。你可以用 TinyPNG 这样的压缩网站(它对 jpg 也有效)或任何软件。

尺寸

你使用的纹理的每个像素,无论图片体积如何,都将被存储在 GPU 上。就像你的硬盘一样,GPU 的存储空间有限。更糟的是,自动生成的 mipmapping 会增加必须存储的像素数量。

尽量把图片尺寸减到最小。

如果你还记得我们关于 mipmapping 说的,Three.js 会反复生成纹理一半大小的版本,直到得到 1x1 纹理。正因为如此,你的纹理宽度和高度必须是 2 的幂。这是必须的,这样 Three.js 才能把纹理尺寸除以 2。

一些例子:512x512、1024x1024 或 512x2048

512、1024 和 2048 都可以不断除以 2 直到变成 1。

如果你使用的纹理宽度或高度不是 2 的幂,Three.js 会尝试把它拉伸到最接近的 2 的幂数值,这可能产生很差的视觉效果,而且你还会在控制台里得到一条警告。

数据

我们还没有测试它,因为我们得先看其他东西,但纹理支持透明。你可能知道,jpg 文件没有 alpha 通道,所以你可能会更愿意用 png。

或者你可以用一张 alpha 贴图,我们会在以后的课程里看到。

如果你使用的是法线纹理(那张紫色的),你很可能会希望每个像素的红、绿、蓝通道都有精确的值,否则你可能会遇到视觉故障。为此,你需要用 png,因为它的无损压缩能保留这些值。

到哪里找纹理

遗憾的是,想找到完美的纹理总是很难。有很多网站,但纹理并不总是合适,而且你可能还得付费。

大概从在网上搜索开始是个好主意。下面是一些我常去的网站。

如果不是个人使用,一定要确保你拥有使用那个纹理的权利。

你也可以用照片和 Photoshop 这样的 2D 软件自己制作,甚至可以用 Substance Designer 之类的软件做程序化纹理。